03 - Temporal 与确定性重 放
前置:01 篇 4.1 节的两种恢复模型。
本篇回答:为什么这类框架禁止在编排代码里用随机数和系统时间、这个约束的边界到底在哪、以及 Agent 代码该怎么组织才不会被它拖住发布节奏。
术语
- Workflow(工作流):编排逻辑,必须确定性
- Activity(活动):实际执行副作用的函数,允许任意不确定操作
- Event History(事件历史):服务端记录的该工作流发生过的全部事件
- Command(命令):工作流代码执行时向服务端发出的意图,例如"启动一个 Activity"
一、它存的是事件,不是状态
02 篇里 LangGraph 存状态快照。Temporal 存事件历史。
「事件历史」这个词听着抽象,先看它字面上到底长什么样。下面是官方文档里一次 Activity 调用的完整记录 —— 一次失败、退避、重试成功:
static/diagrams/activity-execution-with-retry.svg,MIT。注意左边那条竖线:一次 Activity Execution(你代码里的一次调用)里套着两次 Activity Task Execution(实际派给 worker 的两次尝试)。重试是服务端按事件推进的,你的工作流代码全程没有感知,也不需要写重试循环。每一行都是一条只追加、不修改的事件。这就是「存事件」和「存状态」的区别:状态快照只告诉你「现在跑到第几步」,事件历史还留着「第一次失败了、等了多久、第二次才成」这些过程。恢复的时候,前者是把状态读回来接着跑,后者是把事件从头喂一遍,让代码自己重新走到那个位置。
"从第一行重跑"是理解这套机制的关键,它是后面所有约束的来源。
服务端在重放时逐条比对:
When the Workflow's code replays, the Commands that are emitted are compared with the existing Event History. If a corresponding Event already exists within the Event History that matches that command, then the Execution progresses.
「发出 Command、和历史比对、比对上就往前走」这个循环是工作流代码前进的唯一方式。官方把它画成了两条泳道:
static/diagrams/workflow-execution-progession-simple.svg,MIT。上下折返的每一次都是一个「让出点」:代码发出 Command 后就把控制权还给平台,直到平台把 Awaitable 的值填回来才继续。崩溃只可能发生在这些让出点上,所以恢复时要重放到的也正是这些点,而不是任意一行代码中间。这张图也解释了为什么工作流代码里不能自己起线程等外部事件:它前进的每一步都要经过平台,绕过去的那部分不会进历史,重放时也就无从复现。
二、确定性约束
代码要重跑并走出完全相同的路径,任何导致路径分叉的操作都被禁止。
为什 么这条约束躲不掉,看恢复时那一刻发生了什么:
static/diagrams/replay-basic.svg,MIT。下面那条时间轴的蓝色段是重放,绿色段才是真正的新执行。两段用的是同一份代码 —— 没有「恢复模式」的分支,也没有办法在代码里判断自己此刻处在哪一段。「同一份代码、无法自知处在哪一段」这两件事合起来,就把约束逼出来了:既然你写不出「重放时走这边、正常执行走那边」,那就只能要求这份代码本身在两种情况下走法一致。
2.1 禁止清单
| 操作 | 分叉原因 | 正确做法 |
|---|---|---|
random() | 两次取值不同,可能进入不同分支 | 移入 Activity,或用 SDK 的确定性随机 |
time.now() | 同上 | 用 SDK 提供的工作流时钟 |
| 直接发 HTTP / 查数据库 | 重放时会真的再次打到外部系统 | 移入 Activity |
| 直接调用大模型 | 同 prompt 两次输出可能不同 | 移入 Activity |
| 起线程、依赖系统调度顺序 | 执行顺序不可复现 | 用 SDK 的并发原语 |
官方文档明确点名了 LLM 调用:
Workflow code must be deterministic to support replay. To handle non-deterministic operations like API calls, LLM/AI invocations, database queries, and other external interactions, put them in Activities.
2.2 代码组织的实际形态
# ❌ 错误:模型调用直接写在工作流函数里
# 重放时会真的再打一次模型,且返回值可能与历史不同 → 路径分叉
@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> str:
resp = openai.chat.completions.create(...) # 不确定操作,禁止
if "需要深入" in resp.choices[0].message.content:
...
# ✅ 正确:不确定操作封进 Activity,工作流只做编排
@activity.defn
async def call_model(prompt: str) -> str:
"""Activity 内部允许任意不确定操作。
它的返回值会被写进 Event History,重放时直接取历史值,不会重复调用。"""
resp = openai.chat.completions.create(
model="gpt-4o", messages=[{"role": "user", "content": prompt}]
)
return resp.choices[0].message.content
@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> str:
# execute_activity 会产生一个 Command,被记入 Event History。
# 重放时比对到同一条 Command,直接返回历史结果。
summary = await workflow.execute_activity(
call_model,
f"总结 {topic}",
start_to_close_timeout=timedelta(minutes=2),
# 重试策略由框架执行,不需要自己写 try/except 循环
retry_policy=RetryPolicy(maximum_attempts=3),
)
# 这里的分支判断基于 Activity 的返回值。
# 因为返回值来自历史,重放时必然走同一分支 —— 确定性得以保持。
if "需要深入" in summary:
...
2.3 非确定性错误的两个来源
第一类是写错了,改掉即可。第二类才是生产上真正会咬人的 —— 它把发布节奏和工作流实例的生命周期绑在了一起。对一个跑三天的长任务来说,这意味着三天内不能随意改工作流代码。
三、约束的实际边界
"不能改代码"的说法过于笼统。实际受限的只有会产生 Command 的操作。官方列出的是三类:
Starting or cancelling a Timer, Scheduling or cancelling Activity Executions, Starting or cancelling Child Workflow executions
即定时器、Activity、子工作流。
3.1 可以自由修改的
@workflow.defn
class ResearchWorkflow:
@workflow.run
async def run(self, topic: str) -> str:
summary = await workflow.execute_activity(call_model, ...)
# 下面这些都不产生 Command,重放不受影响,可以随时改:
title = summary.strip().split("\n")[0] # 纯计算
title = f"【调研】{title}" # 改字符串拼接方式
workflow.logger.info("已生成标题: %s", title) # 加日志
result = self._format(summary, title) # 重构辅助函数
return result
3.2 不能修改的
# 以下四类改动会破坏与旧历史的兼容:
# 1) 调换两个 Activity 的先后顺序
# 2) 新增一个 Activity
# 3) 删除一个 Activity
# 4) 增删定时器或子工作流
# 需要改时,用 SDK 的版本化 API 让新旧历史各走各的分支。
这条推论把"不能改代码"收窄成了"不能改这三类操作的序列",可操作性完全不同。
3.3 对 Agent 场景的实践建议
Agent 里改动最频繁的恰恰是 prompt 和工具编排。缓解方式:
| 做法 | 效果 |
|---|---|
| prompt、工具调用、模型调用全部放进 Activity | 本来就是必须的;顺带让这些高频改动不触碰 Command 序列 |
| 工作流函数只保留最稳定的流程骨架 | 工作流改得越少,版本兼容负担越轻 |
| prompt 作为参数传入 Activity,而非硬编码 | 改 prompt 变成改配置,完全不动代码 |
四、它换来了什么
02 篇 5.1 节列出的 LangGraph 缺口,在这里都有对应答案:
| 能力 | 机制 |
|---|---|
| 自动恢复 | 服务端知道工作流未结束,会调度另一个 worker 接手,无需自写巡检 |
| 挂起不占资源 | 等待三天期间没有进程运行,定时器由服务端维护 |
| 自动重试 | Activity 失败按策略重试,次数、退避、超时均为配置项 |
| 跨服务编排 | 工作流可调用其他服务的 Activity,不限单进程 |
| 执行记录完整 | Event History 本身就是精确到每次调用的执行记录 |
4.1 最后一条对 Agent 的额外价值
Agent 可观测性里需要自行埋点才能看清的执行过程,在这里是重放机制的副产品 —— 它本来就必须记录每次 Activity 调用。
但重放会污染 trace:同一个工作流可能被重放多次,若在工作流函数里直接埋点,同一步会出现 N 次 span。处理方式见 05 篇。
五、接入成本
| 成本项 | 说 明 |
|---|---|
| 运行一个集群 | Temporal Server 是独立的分布式系统,需要自己的存储(Cassandra / MySQL / PostgreSQL)。有托管版可免运维,需付费 |
| 代码结构调整 | Agent 代码要拆成 Workflow + Activity 两层,不是加装饰器就能完成 |
| 团队认知成本 | 最贵的一项。不理解重放机制的人会写出隐蔽的非确定性缺陷,且只在崩溃恢复时暴露 —— 恰是最不希望出问题的时刻 |
5.1 选型判断
不适合:团队无分布式工作流经验,且诉求仅为"Agent 挂了能续上"。这种情况下 Temporal 属于过度工程,应先看 04 篇。
适合:跨多个服务、运行数天、涉及资金或不可撤销操作的业务流程(订单履约、审批流、资金结算)。这类场景下约束换来的可靠性是划算的,这也是它在金融与电商领域被广泛采用的原因。
下一篇 → 04 - DBOS 与 Restate
← 回到 专题索引 · Agent Infra 板块总览